之前完成了後端的核心預約 API(BookingService.gs)與取消/改期規章(CancellationService.gs)後,今天我們進入 模組 D:前端互動介面與全日曆整合。
本篇將實作系統的門面——Index.html,透過 HTML5、Tailwind CSS、FullCalendar v6 以及原生 JavaScript,將後端 Apps Script API 與圖形化日曆元件完美對接,為 NGO 職工提供流暢、直覺且響應式的房間預約體驗。
為確保系統輕量且免除複雜的 Build Process(如 Webpack/Vite),我們採用 CDN 引入 方式,直接在 Google Apps Script 的 HTML 範本中完成前端開發:
google.script.run:Apps Script 專用的非同步(Asynchronous)RPC 機制,用以呼叫後端的 getAvailableSlots 與 submitNewBooking。Index.html)請在 Apps Script 專案中新增名為 Index.html 的 HTML 檔案,並寫入以下程式碼:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NGO 房間與資源預約系統</title>
<!-- 1. 載入 Tailwind CSS & FullCalendar CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js'></script>
<style>
/* 調整 FullCalendar 樣式符合 NGO 簡約風格 */
.fc-event { cursor: pointer; }
.fc-toolbar-title { font-size: 1.25rem !important; font-weight: 700; color: #1e293b; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans min-h-screen">
<!-- 頂部導覽列 -->
<header class="bg-indigo-600 text-white shadow-md py-4 px-6 mb-6 flex justify-between items-center">
<h1 class="text-xl font-bold flex items-center gap-2">
🏢 NGO 資源預約平台 <span class="text-xs bg-indigo-500 px-2 py-1 rounded text-indigo-100 font-normal">Day 21 Build</span>
</h1>
<div id="userInfo" class="text-sm opacity-90">職工載入中...</div>
</header>
<main class="max-w-7xl mx-auto px-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左側:房間選擇與預約表單 -->
<section class="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<h2 class="text-lg font-bold mb-4 text-slate-700 border-b pb-2">➕ 新增房間預約</h2>
<form id="bookingForm" onsubmit="handleFormSubmit(event)" class="space-y-4">
<!-- 房間選擇 (對應指定 5 個房間) -->
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">選擇房間 / 資源</label>
<select id="roomId" onchange="refreshCalendar()" class="w-full border-slate-300 rounded-lg p-2.5 border focus:ring-2 focus:ring-indigo-500 outline-none">
<option value="A1">活動室 A1</option>
<option value="A2">活動室 A2</option>
<option value="B1">活動室 B1</option>
<option value="B2">活動室 B2</option>
<option value="TALK_ROOM">洽談室</option>
</select>
</div>
<!-- 預約日期與時間 -->
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">預約日期</label>
<input type="date" id="bookingDate" required onchange="refreshCalendar()" class="w-full border-slate-300 rounded-lg p-2.5 border focus:ring-2 focus:ring-indigo-500 outline-none">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">開始時間</label>
<input type="time" id="startTime" required class="w-full border-slate-300 rounded-lg p-2 border focus:ring-2 focus:ring-indigo-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">結束時間</label>
<input type="time" id="endTime" required class="w-full border-slate-300 rounded-lg p-2 border focus:ring-2 focus:ring-indigo-500 outline-none">
</div>
</div>
<!-- 預約用途 (給 Gemini AI 審查) -->
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">預約用途說明 (Gemini AI 審核)</label>
<textarea id="purpose" rows="3" required placeholder="例如:青少年小組面談、個案輔導聚會..." class="w-full border-slate-300 rounded-lg p-2.5 border focus:ring-2 focus:ring-indigo-500 outline-none text-sm"></textarea>
</div>
<!-- 提交按鈕與狀態顯示 -->
<button type="submit" id="submitBtn" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-3 rounded-lg transition-all shadow-sm">
確認提交預約
</button>
<div id="statusAlert" class="hidden p-3 rounded-lg text-sm mt-3"></div>
</form>
</section>
<!-- 右側:FullCalendar 全日曆即時視圖 -->
<section class="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<div id="calendar"></div>
</section>
</main>
<!-- 3. 前端 JavaScript 邏輯與 API 銜接 -->
<script>
let calendar;
// 初始化頁面
document.addEventListener('DOMContentLoaded', function() {
// 預設日期設為今天
const today = new Date().toISOString().split('T')[0];
document.getElementById('bookingDate').value = today;
// 初始化 FullCalendar
const calendarEl = document.getElementById('calendar');
calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridDay',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridDay,timeGridWeek'
},
slotMinTime: '08:00:00',
slotMaxTime: '22:00:00',
allDaySlot: false,
height: '650px',
locale: 'zh-tw'
});
calendar.render();
// 載入當日資料
refreshCalendar();
});
// 呼叫 GAS 後端取得時段資料
function refreshCalendar() {
const dateStr = document.getElementById('bookingDate').value;
const roomId = document.getElementById('roomId').value;
if (!dateStr || !roomId) return;
// 呼叫BookingService.gs API
google.script.run
.withSuccessHandler(renderCalendarEvents)
.withFailureHandler(handleApiError)
.getAvailableSlots(dateStr, roomId);
}
// 將後端傳回的 Slots 渲染成 FullCalendar 事件
function renderCalendarEvents(response) {
if (!response.success) return;
calendar.removeAllEvents();
response.slots.forEach(slot => {
calendar.addEvent({
title: slot.isAvailable ? '✅ 可預約' : '❌ 已額滿',
start: slot.startTimeIso,
end: slot.endTimeIso,
backgroundColor: slot.isAvailable ? '#10b981' : '#ef4444',
borderColor: 'transparent'
});
});
}
// 提交預約表單處理
function handleFormSubmit(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const statusAlert = document.getElementById('statusAlert');
submitBtn.disabled = true;
submitBtn.innerText = "⏳ 提交中 & 執行 AI 審查...";
statusAlert.className = "hidden p-3 rounded-lg text-sm mt-3";
const dateStr = document.getElementById('bookingDate').value;
const roomId = document.getElementById('roomId').value;
const startTime = document.getElementById('startTime').value;
const endTime = document.getElementById('endTime').value;
const purpose = document.getElementById('purpose').value;
const startTimeIso = `${dateStr}T${startTime}:00.000Z`;
const endTimeIso = `${dateStr}T${endTime}:00.000Z`;
// 呼叫 Apps Script 後端 submitNewBooking API
google.script.run
.withSuccessHandler(onBookingSuccess)
.withFailureHandler(handleApiError)
.submitNewBooking("worker@ngo.org", roomId, startTimeIso, endTimeIso, purpose);
}
function onBookingSuccess(result) {
const submitBtn = document.getElementById('submitBtn');
const statusAlert = document.getElementById('statusAlert');
submitBtn.disabled = false;
submitBtn.innerText = "確認提交預約";
statusAlert.classList.remove('hidden');
if (result.success) {
statusAlert.className = "p-3 rounded-lg text-sm mt-3 bg-emerald-50 text-emerald-800 border border-emerald-200";
statusAlert.innerHTML = `<b>🎉 ${result.message}</b><br>🤖 AI 標註:${result.aiAnalysis}<br>💰 預算金額:HKD $${result.estimatedFee}`;
refreshCalendar(); // 重新整理日曆
} else {
statusAlert.className = "p-3 rounded-lg text-sm mt-3 bg-rose-50 text-rose-800 border border-rose-200";
statusAlert.innerText = result.message;
}
}
function handleApiError(error) {
const submitBtn = document.getElementById('submitBtn');
const statusAlert = document.getElementById('statusAlert');
submitBtn.disabled = false;
submitBtn.innerText = "確認提交預約";
statusAlert.classList.remove('hidden');
statusAlert.className = "p-3 rounded-lg text-sm mt-3 bg-rose-50 text-rose-800 border border-rose-200";
statusAlert.innerText = "❌ 發生系統錯誤:" + error.message;
}
</script>
</body>
</html>
Code.gs)為使 Index.html 能在瀏覽器中渲染,請確保 Code.gs 中包含以下 HTTP doGet 處理常式:
/**
* Apps Script Web App 入口點 (HTTP GET)
*/
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('NGO 房間與資源預約系統')
.setXframeOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
[使用者於 Index.html 操作]
│
├─► 1. 切換日期/房間 (A1/A2/B1/B2/洽談室) ──► google.script.run.getAvailableSlots()
│ │
│ ▼
│ [後端 BookingService.gs]
│ │
├─◄ 2. 渲染綠色/紅色區塊 ◄──────────────────────┘
│
└─► 3. 填表提交預約 ─────────────────────────► google.script.run.submitNewBooking()
│
▼
[1. 重疊衝突排查]
[2. Gemini AI 審查]
[3. Telegram 推播]
今天我們完成了 FullCalendar 前端互動介面 (Index.html),精確對接 5 個目標房間(活動室 A1、A2、B1、B2、洽談室),實現了預約狀況視覺化、非同步提交表單、Gemini AI 審核結果實時回傳與 Telegram 警示的閉環整合。